The extension handling is currently intertwined with the UI.
Splitting it out provides a clearer separation, and will allow
us to switch to a model-based UI later.
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3067>
src/st/st-icon-theme.c
subprojects/extensions-app/data/metainfo/org.gnome.Extensions.metainfo.xml.in
subprojects/extensions-app/data/org.gnome.Extensions.desktop.in.in
+subprojects/extensions-app/js/extensionManager.js
subprojects/extensions-app/js/extensionRow.js
subprojects/extensions-app/js/extensionsWindow.js
subprojects/extensions-app/data/ui/extension-row.ui
--- /dev/null
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import GObject from 'gi://GObject';
+
+import {
+ ExtensionState, ExtensionType, deserializeExtension
+} from './misc/extensionUtils.js';
+
+const GnomeShellIface = loadInterfaceXML('org.gnome.Shell.Extensions');
+const GnomeShellProxy = Gio.DBusProxy.makeProxyWrapper(GnomeShellIface);
+
+let shellVersion;
+
+function loadInterfaceXML(iface) {
+ const uri = `resource:///org/gnome/Extensions/dbus-interfaces/${iface}.xml`;
+ const f = Gio.File.new_for_uri(uri);
+
+ try {
+ let [ok_, bytes] = f.load_contents(null);
+ return new TextDecoder().decode(bytes);
+ } catch (e) {
+ console.error(`Failed to load D-Bus interface ${iface}`);
+ }
+
+ return null;
+}
+
+class Extension {
+ constructor(variant) {
+ this.update(variant);
+ }
+
+ update(variant) {
+ const deserialized = deserializeExtension(variant);
+
+ const {
+ uuid, type, state, error, hasPrefs, hasUpdate, canChange, metadata,
+ } = deserialized;
+
+ if (!this._uuid)
+ this._uuid = uuid;
+
+ if (this._uuid !== uuid)
+ throw new Error(`Invalid update of extension ${this._uuid} with data from ${uuid}`);
+
+ const {name} = metadata;
+ this._name = name;
+ [this._keywords] = GLib.str_tokenize_and_fold(name, null);
+
+ const [desc] = metadata.description.split('\n');
+ this._description = desc;
+
+ this._type = type;
+ this._errorDetail = error;
+ this._state = state;
+
+ const creator = metadata.creator ?? '';
+ this._creator = creator;
+
+ const url = metadata.url ?? '';
+ this._url = url;
+
+ const version = String(
+ metadata['version-name'] || metadata['version'] || '');
+ this._version = version;
+
+ this._hasPrefs = hasPrefs;
+ this._hasUpdate = hasUpdate;
+ this._canChange = canChange;
+ }
+
+ get uuid() {
+ return this._uuid;
+ }
+
+ get name() {
+ return this._name;
+ }
+
+ get description() {
+ return this._description;
+ }
+
+ get state() {
+ return this._state;
+ }
+
+ get creator() {
+ return this._creator;
+ }
+
+ get url() {
+ return this._url;
+ }
+
+ get version() {
+ return this._version;
+ }
+
+ get keywords() {
+ return this._keywords;
+ }
+
+ get error() {
+ if (!this.hasError)
+ return '';
+
+ if (this.state === ExtensionState.OUT_OF_DATE) {
+ return this.version !== ''
+ ? _('The installed version of this extension (%s) is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(this.version, shellVersion)
+ : _('The installed version of this extension is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(shellVersion);
+ }
+
+ const message = [
+ _('An error has occurred in this extension. This could cause issues elsewhere in the system. It is recommended to turn the extension off until the error is resolved.'),
+ ];
+
+ if (this._errorDetail) {
+ message.push(
+ // translators: Details for an extension error
+ _('Error details:'), this._errorDetail);
+ }
+
+ return message.join('\n\n');
+ }
+
+ get hasError() {
+ return this.state === ExtensionState.OUT_OF_DATE ||
+ this.state === ExtensionState.ERROR;
+ }
+
+ get hasPrefs() {
+ return this._hasPrefs;
+ }
+
+ get hasUpdate() {
+ return this._hasUpdate;
+ }
+
+ get hasVersion() {
+ return this._version !== '';
+ }
+
+ get canChange() {
+ return this._canChange;
+ }
+
+ get isUser() {
+ return this._type === ExtensionType.PER_USER;
+ }
+}
+
+export const ExtensionManager = GObject.registerClass({
+ Properties: {
+ 'user-extensions-enabled': GObject.ParamSpec.boolean(
+ 'user-extensions-enabled', null, null,
+ GObject.ParamFlags.READWRITE,
+ true),
+ 'n-updates': GObject.ParamSpec.int(
+ 'n-updates', null, null,
+ GObject.ParamFlags.READABLE,
+ 0, 999, 0),
+ 'failed': GObject.ParamSpec.boolean(
+ 'failed', null, null,
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+ Signals: {
+ 'extension-added': {param_types: [GObject.TYPE_JSOBJECT]},
+ 'extension-removed': {param_types: [GObject.TYPE_JSOBJECT]},
+ 'extension-changed': {param_types: [GObject.TYPE_JSOBJECT], flags: GObject.SignalFlags.DETAILED},
+ 'extensions-loaded': {},
+ },
+}, class ExtensionManager extends GObject.Object {
+ constructor() {
+ super();
+
+ this._extensions = new Map();
+
+ this._proxyReady = false;
+ this._shellProxy = new GnomeShellProxy(Gio.DBus.session,
+ 'org.gnome.Shell.Extensions', '/org/gnome/Shell/Extensions',
+ () => {
+ this._proxyReady = true;
+ shellVersion = this._shellProxy.ShellVersion;
+
+ this._shellProxy.connect('notify::g-name-owner',
+ () => this.notify('failed'));
+ this.notify('failed');
+ });
+
+ this._shellProxy.connect('g-properties-changed', (proxy, properties) => {
+ const enabledChanged = !!properties.lookup_value('UserExtensionsEnabled', null);
+ if (enabledChanged)
+ this.notify('user-extensions-enabled');
+ });
+ this._shellProxy.connectSignal(
+ 'ExtensionStateChanged', this._onExtensionStateChanged.bind(this));
+
+ this._loadExtensions().catch(console.error);
+ }
+
+ get userExtensionsEnabled() {
+ return this._shellProxy.UserExtensionsEnabled ?? false;
+ }
+
+ set userExtensionsEnabled(enabled) {
+ this._shellProxy.UserExtensionsEnabled = enabled;
+ }
+
+ get nUpdates() {
+ let nUpdates = 0;
+ for (const ext of this._extensions.values()) {
+ if (ext.isUser && ext.hasUpdate)
+ nUpdates++;
+ }
+ return nUpdates;
+ }
+
+ get failed() {
+ return this._proxyReady && this._shellProxy.gNameOwner === null;
+ }
+
+ enableExtension(uuid) {
+ this._shellProxy.EnableExtensionAsync(uuid).catch(console.error);
+ }
+
+ disableExtension(uuid) {
+ this._shellProxy.DisableExtensionAsync(uuid).catch(console.error);
+ }
+
+ uninstallExtension(uuid) {
+ this._shellProxy.UninstallExtensionAsync(uuid).catch(console.error);
+ }
+
+ openExtensionPrefs(uuid, parentHandle) {
+ this._shellProxy.OpenExtensionPrefsAsync(uuid,
+ parentHandle,
+ {modal: new GLib.Variant('b', true)}).catch(console.error);
+ }
+
+ checkForUpdates() {
+ this._shellProxy.CheckForUpdatesAsync().catch(console.error);
+ }
+
+ _addExtension(extension) {
+ const {uuid} = extension;
+ if (this._extensions.has(uuid))
+ return;
+
+ this._extensions.set(uuid, extension);
+ this.emit('extension-added', extension);
+ }
+
+ _removeExtension(extension) {
+ const {uuid} = extension;
+ if (this._extensions.delete(uuid))
+ this.emit('extension-removed', extension);
+ }
+
+ async _loadExtensions() {
+ const [extensionsMap] = await this._shellProxy.ListExtensionsAsync();
+
+ for (let uuid in extensionsMap) {
+ const extension = new Extension(extensionsMap[uuid]);
+ this._addExtension(extension);
+ }
+ this.emit('extensions-loaded');
+ }
+
+ _onExtensionStateChanged(p, sender, [uuid, newState]) {
+ const extension = this._extensions.get(uuid);
+
+ if (extension)
+ extension.update(newState);
+
+ if (!extension)
+ this._addExtension(new Extension(newState));
+ else if (extension.state === ExtensionState.UNINSTALLED)
+ this._removeExtension(extension);
+ else
+ this.emit(`extension-changed::${uuid}`, extension);
+
+ if (this._updatesCheckId)
+ return;
+
+ this._updatesCheckId = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT, 1, () => {
+ this.notify('n-updates');
+
+ delete this._updatesCheckId;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+});
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
-import {ExtensionState, ExtensionType, deserializeExtension} from './misc/extensionUtils.js';
+import {ExtensionState} from './misc/extensionUtils.js';
export const ExtensionRow = GObject.registerClass({
GTypeName: 'ExtensionRow',
this._app = Gio.Application.get_default();
this._extension = extension;
- [this._keywords] = GLib.str_tokenize_and_fold(this.name, null);
-
this._actionGroup = new Gio.SimpleActionGroup();
this.insert_action_group('row', this._actionGroup);
let action;
action = new Gio.SimpleAction({
name: 'show-prefs',
- enabled: this.hasPrefs,
+ enabled: extension.hasPrefs,
});
action.connect('activate', () => {
this._detailsPopover.popdown();
- this.get_root().openPrefs(this.uuid);
+ this.get_root().openPrefs(extension);
});
this._actionGroup.add_action(action);
action = new Gio.SimpleAction({
name: 'show-url',
- enabled: this.url !== '',
+ enabled: extension.url !== '',
});
action.connect('activate', () => {
this._detailsPopover.popdown();
Gio.AppInfo.launch_default_for_uri(
- this.url, this.get_display().get_app_launch_context());
+ extension.url, this.get_display().get_app_launch_context());
});
this._actionGroup.add_action(action);
action = new Gio.SimpleAction({
name: 'uninstall',
- enabled: this.type === ExtensionType.PER_USER,
+ enabled: extension.isUser,
});
action.connect('activate', () => {
this._detailsPopover.popdown();
- this.get_root().uninstall(this.uuid);
+ this.get_root().uninstall(extension);
});
this._actionGroup.add_action(action);
const state = action.get_state();
action.change_state(new GLib.Variant('b', !state.get_boolean()));
});
-
action.connect('change-state', (a, state) => {
+ const {uuid} = this._extension;
if (state.get_boolean())
- this._app.shellProxy.EnableExtensionAsync(this.uuid).catch(console.error);
+ this._app.extensionManager.enableExtension(uuid);
else
- this._app.shellProxy.DisableExtensionAsync(this.uuid).catch(console.error);
+ this._app.extensionManager.disableExtension(uuid);
});
this._actionGroup.add_action(action);
- this.title = this.name;
+ this.title = extension.name;
- const desc = this._extension.metadata.description.split('\n')[0];
- this._descriptionLabel.label = desc;
+ this._descriptionLabel.label = extension.description;
this.connect('destroy', this._onDestroy.bind(this));
- this._extensionStateChangedId = this._app.shellProxy.connectSignal(
- 'ExtensionStateChanged', (p, sender, [uuid, newState]) => {
- if (this.uuid !== uuid)
- return;
-
- this._extension = deserializeExtension(newState);
- this._updateState();
- });
+ this._extensionStateChangedId = this._app.extensionManager.connect(
+ `extension-changed::${extension.uuid}`, () => this._updateState());
this._updateState();
}
- get uuid() {
- return this._extension.uuid;
- }
-
- get name() {
- return this._extension.metadata.name;
- }
-
- get hasPrefs() {
- return this._extension.hasPrefs;
- }
-
- get hasUpdate() {
- return this._extension.hasUpdate || false;
- }
-
- get hasError() {
- const {state} = this._extension;
- return state === ExtensionState.OUT_OF_DATE ||
- state === ExtensionState.ERROR;
- }
-
- get type() {
- return this._extension.type;
- }
-
- get creator() {
- return this._extension.metadata.creator || '';
- }
-
- get url() {
- return this._extension.metadata.url || '';
- }
-
- get version() {
- return this._extension.metadata['version-name'] || this._extension.metadata.version || '';
- }
-
- get error() {
- if (!this.hasError)
- return '';
-
- if (this._extension.state === ExtensionState.OUT_OF_DATE) {
- const {ShellVersion: shellVersion} = this._app.shellProxy;
- return this.version !== ''
- ? _('The installed version of this extension (%s) is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(this.version, shellVersion)
- : _('The installed version of this extension is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(shellVersion);
- }
-
- const message = [
- _('An error has occurred in this extension. This could cause issues elsewhere in the system. It is recommended to turn the extension off until the error is resolved.'),
- ];
-
- if (this._extension.error) {
- message.push(
- // translators: Details for an extension error
- _('Error details:'), this._extension.error);
- }
-
- return message.join('\n\n');
- }
-
- get keywords() {
- return this._keywords;
+ get extension() {
+ return this._extension;
}
_updateState() {
if (!action.enabled)
this._switch.active = state;
- this._updatesButton.visible = this.hasUpdate;
- this._errorButton.visible = this.hasError;
- this._errorLabel.label = this.error;
+ this._updatesButton.visible = this._extension.hasUpdate;
+ this._errorButton.visible = this._extension.hasError;
+ this._errorLabel.label = this._extension.error;
- this._versionLabel.label = _('Version %s').format(this.version.toString());
- this._versionLabel.visible = this.version !== '';
+ this._versionLabel.label = _('Version %s').format(this._extension.version);
+ this._versionLabel.visible = this._extension.version !== '';
}
_onDestroy() {
- if (!this._app.shellProxy)
- return;
-
if (this._extensionStateChangedId)
- this._app.shellProxy.disconnectSignal(this._extensionStateChangedId);
- this._extensionStateChangedId = 0;
+ this._app.extensionManager.disconnect(this._extensionStateChangedId);
+ delete this._extensionStateChangedId;
}
});
-
import * as Config from './misc/config.js';
import {ExtensionRow} from './extensionRow.js';
-import {ExtensionState, ExtensionType, deserializeExtension} from './misc/extensionUtils.js';
Gio._promisify(Gio.DBusConnection.prototype, 'call');
Gio._promisify(Shew.WindowExporter.prototype, 'export');
if (Config.PROFILE === 'development')
this.add_css_class('devel');
- this._updatesCheckId = 0;
-
this._exporter = new Shew.WindowExporter({window: this});
this._exportedHandle = '';
name: 'user-extensions-enabled',
state: 'false',
change_state: (a, state) => {
- this._shellProxy.UserExtensionsEnabled = state.get_boolean();
+ const {extensionManager} = this.application;
+ extensionManager.userExtensionsEnabled = state.get_boolean();
},
}]);
}));
this._systemList.connect('row-activated', (_list, row) => row.activate());
- this._shellProxy.connectSignal('ExtensionStateChanged',
- this._onExtensionStateChanged.bind(this));
-
- this._shellProxy.connect('g-properties-changed',
+ const {extensionManager} = this.application;
+ extensionManager.connect('notify::failed',
+ () => this._syncVisiblePage());
+ extensionManager.connect('notify::n-updates',
+ () => this._checkUpdates());
+ extensionManager.connect('notify::user-extensions-enabled',
this._onUserExtensionsEnabledChanged.bind(this));
this._onUserExtensionsEnabledChanged();
- this._scanExtensions();
- }
+ extensionManager.connect('extension-added',
+ (mgr, extension) => this._addExtensionRow(extension));
+ extensionManager.connect('extension-removed',
+ (mgr, extension) => this._removeExtensionRow(extension));
+ extensionManager.connect('extension-changed',
+ (mgr, extension) => {
+ const row = this._findExtensionRow(extension);
+ const isUser = row?.get_parent() === this._userList;
+ if (extension.isUser !== isUser) {
+ this._removeExtensionRow(extension);
+ this._addExtensionRow(extension);
+ }
+ });
- get _shellProxy() {
- return this.application.shellProxy;
+ extensionManager.connect('extensions-loaded',
+ () => this._extensionsLoaded());
}
- uninstall(uuid) {
- const row = this._findExtensionRow(uuid);
-
+ uninstall(extension) {
const dialog = new Gtk.MessageDialog({
transient_for: this,
modal: true,
- text: _('Remove “%s”?').format(row.name),
+ text: _('Remove “%s”?').format(extension.name),
secondary_text: _('If you remove the extension, you need to return to download it if you want to enable it again'),
});
.get_style_context().add_class('destructive-action');
dialog.connect('response', (dlg, response) => {
+ const {extensionManager} = this.application;
+
if (response === Gtk.ResponseType.ACCEPT)
- this._shellProxy.UninstallExtensionAsync(uuid).catch(console.error);
+ extensionManager.uninstallExtension(extension.uuid);
dialog.destroy();
});
dialog.present();
}
- async openPrefs(uuid) {
+ async openPrefs(extension) {
if (!this._exportedHandle) {
try {
this._exportedHandle = await this._exporter.export();
}
}
- this._shellProxy.OpenExtensionPrefsAsync(uuid,
- this._exportedHandle,
- {modal: new GLib.Variant('b', true)}).catch(console.error);
+ const {extensionManager} = this.application;
+ extensionManager.openExtensionPrefs(extension.uuid, this._exportedHandle);
}
_showAbout() {
}
_sortList(row1, row2) {
- return row1.name.localeCompare(row2.name);
+ const {name: name1} = row1.extension;
+ const {name: name2} = row2.extension;
+ return name1.localeCompare(name2);
}
_filterList(row) {
+ const {keywords} = row.extension;
return this._searchTerms.every(
- t => row.keywords.some(k => k.startsWith(t)));
+ t => keywords.some(k => k.startsWith(t)));
}
- _findExtensionRow(uuid) {
+ _findExtensionRow(extension) {
return [
...this._userList,
...this._systemList,
- ].find(c => c.uuid === uuid);
+ ].find(c => c.extension === extension);
}
_onUserExtensionsEnabledChanged() {
+ const {userExtensionsEnabled} = this.application.extensionManager;
const action = this.lookup_action('user-extensions-enabled');
- action.set_state(
- new GLib.Variant('b', this._shellProxy.UserExtensionsEnabled));
- }
-
- _onExtensionStateChanged(proxy, senderName, [uuid, newState]) {
- const extension = deserializeExtension(newState);
- let row = this._findExtensionRow(uuid);
-
- this._queueUpdatesCheck();
-
- // the extension's type changed; remove the corresponding row
- // and reset the variable to null so that we create a new row
- // below and add it to the appropriate list
- if (row && row.type !== extension.type) {
- row.get_parent().remove(row);
- row = null;
- }
-
- if (row) {
- if (extension.state === ExtensionState.UNINSTALLED)
- row.get_parent().remove(row);
- } else {
- this._addExtensionRow(extension);
- }
-
- this._syncListVisibility();
- }
-
- async _scanExtensions() {
- try {
- const [extensionsMap] = await this._shellProxy.ListExtensionsAsync();
-
- for (let uuid in extensionsMap) {
- const extension = deserializeExtension(extensionsMap[uuid]);
- this._addExtensionRow(extension);
- }
- this._extensionsLoaded();
- } catch (e) {
- if (e instanceof Gio.DBusError) {
- console.log(`Failed to connect to shell proxy: ${e}`);
- this._mainStack.visible_child_name = 'noshell';
- } else {
- throw e;
- }
- }
+ action.set_state(new GLib.Variant('b', userExtensionsEnabled));
}
_addExtensionRow(extension) {
const row = new ExtensionRow(extension);
- if (row.type === ExtensionType.PER_USER)
+ if (extension.isUser)
this._userList.append(row);
else
this._systemList.append(row);
- }
-
- _queueUpdatesCheck() {
- if (this._updatesCheckId)
- return;
- this._updatesCheckId = GLib.timeout_add_seconds(
- GLib.PRIORITY_DEFAULT, 1, () => {
- this._checkUpdates();
+ this._syncListVisibility();
+ }
- this._updatesCheckId = 0;
- return GLib.SOURCE_REMOVE;
- });
+ _removeExtensionRow(extension) {
+ const row = this._findExtensionRow(extension);
+ if (row)
+ row.get_parent().remove(row);
+ this._syncListVisibility();
}
_syncListVisibility() {
this._userGroup.visible = [...this._userList].length > 1;
this._systemGroup.visible = [...this._systemList].length > 1;
- if (this._userGroup.visible || this._systemGroup.visible)
+ this._syncVisiblePage();
+ }
+
+ _syncVisiblePage() {
+ const {extensionManager} = this.application;
+
+ if (extensionManager.failed)
+ this._mainStack.visible_child_name = 'noshell';
+ else if (this._userGroup.visible || this._systemGroup.visible)
this._mainStack.visible_child_name = 'main';
else
this._mainStack.visible_child_name = 'placeholder';
}
_checkUpdates() {
- const nUpdates = [...this._userList].filter(c => c.hasUpdate).length;
+ const {nUpdates} = this.application.extensionManager;
this._updatesBanner.title = Gettext.ngettext(
'%d extension will be updated on next login.',
import Adw from 'gi://Adw?version=1';
import GLib from 'gi://GLib';
-import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import {setConsoleLogDomain} from 'console';
Package.initFormat();
+import {ExtensionManager} from './extensionManager.js';
import {ExtensionsWindow} from './extensionsWindow.js';
-const GnomeShellIface = loadInterfaceXML('org.gnome.Shell.Extensions');
-const GnomeShellProxy = Gio.DBusProxy.makeProxyWrapper(GnomeShellIface);
-
-function loadInterfaceXML(iface) {
- const uri = `resource:///org/gnome/Extensions/dbus-interfaces/${iface}.xml`;
- const f = Gio.File.new_for_uri(uri);
-
- try {
- let [ok_, bytes] = f.load_contents(null);
- return new TextDecoder().decode(bytes);
- } catch (e) {
- console.error(`Failed to load D-Bus interface ${iface}`);
- }
-
- return null;
-}
-
var Application = GObject.registerClass(
class Application extends Adw.Application {
_init() {
this.connect('window-removed', (a, window) => window.run_dispose());
}
- get shellProxy() {
- return this._shellProxy;
+ get extensionManager() {
+ return this._extensionManager;
}
vfunc_activate() {
- this._shellProxy.CheckForUpdatesAsync().catch(console.error);
+ this._extensionManager.checkForUpdates();
this._window.present();
}
this.set_accels_for_action('app.quit', ['<Primary>q']);
- this._shellProxy = new GnomeShellProxy(Gio.DBus.session,
- 'org.gnome.Shell.Extensions', '/org/gnome/Shell/Extensions');
+ this._extensionManager = new ExtensionManager();
this._window = new ExtensionsWindow({application: this});
}
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/Extensions@profile@/js">
+ <file>extensionManager.js</file>
<file>extensionRow.js</file>
<file>extensionsWindow.js</file>
<file>main.js</file>